
第 10 篇我們做出了基本的 routing { get("/path") { ... } },但寫 REST API 時,你會想要把相關的路由放在一起
routing {
route("/api/v1") {
route("/users") {
get { ok("list users") }
get("/{id}") { ok("user ${pathParam("id")}") }
post { created("user created") }
}
}
}
這篇要做兩件事,路由群組的前綴拼接,以及讓 TestKit 也支援 routing { } DSL
巢狀路由的關鍵不在 DSL 語法,而在「路徑合併規則」,先寫測試把規則確認清楚,檔案放 PathNormalizeTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
class PathNormalizeTest {
@Test
fun `normal prefix and path`() {
assertEquals("/api/users", normalizePath("/api", "/users"))
}
@Test
fun `prefix with trailing slash`() {
assertEquals("/api/users", normalizePath("/api/", "/users"))
}
@Test
fun `path without leading slash`() {
assertEquals("/api/users", normalizePath("/api", "users"))
}
@Test
fun `prefix with multiple trailing slashes`() {
assertEquals("/api/users", normalizePath("/api//", "/users"))
}
@Test
fun `empty path means use prefix as-is`() {
assertEquals("/api", normalizePath("/api", ""))
}
@Test
fun `empty prefix with path`() {
assertEquals("/users", normalizePath("", "/users"))
}
@Test
fun `both empty means root`() {
assertEquals("/", normalizePath("", ""))
}
@Test
fun `deeply nested prefixes`() {
val step1 = normalizePath("/api", "/v1")
val step2 = normalizePath(step1, "/users")
val step3 = normalizePath(step2, "/{id}")
assertEquals("/api/v1/users/{id}", step3)
}
}
有了測試,實作就很直接
normalizePath 不屬於 RoutingBuilder 也不屬於 RouteGroup,這篇後面兩個 class 都會呼叫它,所以寫成 top-level function,開一個新檔案 PathNormalize.kt 放它
fun normalizePath(prefix: String, path: String): String {
if (prefix.isEmpty() && path.isEmpty()) {
return "/"
}
if (prefix.isEmpty()) {
return if (path.startsWith("/")) path else "/$path"
}
if (path.isEmpty()) {
return prefix
}
val cleanPrefix = prefix.trimEnd('/')
val cleanPath = if (path.startsWith("/")) path else "/$path"
return cleanPrefix + cleanPath
}
邏輯很簡單,把 prefix 尾巴的 / 去掉,確保 path 開頭有 /,然後拼起來,三個 early return 處理邊界情況
為什麼不用 URI.resolve() 或其他標準函式 ? 因為我們的路徑合併是「prefix 疊加」,不是 URL 的相對路徑解析,/api + /users 在 URI 規範裡會變成 /users (因為 /users 是絕對路徑),這不是我們要的行為
normalizePath 只管兩個字串怎麼拼,接下來要做的 RouteGroup 才是把這個規則套到每一條路由上的人,它不需要 Router,也不需要 TestKit,給它一個 MutableList<Route>,看它往裡面加了什麼就好,檔案放 RouteGroupTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
class RouteGroupTest {
@Test
fun `group prepends its prefix to every path`() {
val routes = mutableListOf<Route>()
val group = RouteGroup("/api", routes)
group.get("/users") { ok("get") }
group.post("/users") { created("post") }
group.put("/users/{id}") { ok("put") }
group.delete("/users/{id}") { ok("delete") }
assertEquals(
listOf(
"GET" to "/api/users",
"POST" to "/api/users",
"PUT" to "/api/users/{id}",
"DELETE" to "/api/users/{id}",
),
routes.map { it.method to it.path },
)
}
@Test
fun `overload without path registers the prefix itself`() {
val routes = mutableListOf<Route>()
val group = RouteGroup("/users", routes)
group.get { ok("list") }
group.post { created("create") }
group.put { ok("replace") }
group.delete { ok("remove") }
assertEquals(
listOf(
"GET" to "/users",
"POST" to "/users",
"PUT" to "/users",
"DELETE" to "/users",
),
routes.map { it.method to it.path },
)
}
@Test
fun `nested group stacks prefixes into the same list`() {
val routes = mutableListOf<Route>()
val group = RouteGroup("/api", routes)
group.route("/v1") {
get("/users") { ok("list") }
route("/users") {
get("/{id}") { ok("one") }
}
}
assertEquals(
listOf("/api/v1/users", "/api/v1/users/{id}"),
routes.map { it.path },
)
}
@Test
fun `group keeps the handler it was given`() {
val routes = mutableListOf<Route>()
val handler: RelixHandler = { ok("Hello!") }
RouteGroup("/api", routes).get("/hello", handler)
assertSame(handler, routes.single().handler)
}
}
第一個把四個 method 都確認一次,驗 prefix 有沒有正確疊到每一條路由上,順便確認 method 字串沒寫錯
第二個驗不帶 path 的那組多載,get { } 註冊的路徑就是 group 的 prefix 本身,put { } 和 delete { } 後面的整合測試不會碰到,哪個多載委派錯了 (例如 delete(handler) 手滑寫成 get("", handler)),只有這個測試看得出來
第三個是設計重點的測試版本,測試自己拿著那份 routes,巢狀兩層之後兩條路由都出現在同一份 list 裡,代表 RouteGroup 沒有自己收集一份再往上交
第四個跟第 10 篇的 RoutingBuilderTest 一樣用 assertSame,group 只加 prefix,不會偷偷包一層 handler
RouteGroup 是巢狀路由的核心,它持有一個 currentPrefix,所有在它上面註冊的路由都會自動加上這個前綴,檔案放 RouteGroup.kt
@RelixDsl
class RouteGroup(
private val prefix: String,
private val routes: MutableList<Route>,
) {
fun get(path: String, handler: RelixHandler) {
routes += Route("GET", normalizePath(prefix, path), handler)
}
fun get(handler: RelixHandler) = get("", handler)
fun post(path: String, handler: RelixHandler) {
routes += Route("POST", normalizePath(prefix, path), handler)
}
fun post(handler: RelixHandler) = post("", handler)
fun put(path: String, handler: RelixHandler) {
routes += Route("PUT", normalizePath(prefix, path), handler)
}
fun put(handler: RelixHandler) = put("", handler)
fun delete(path: String, handler: RelixHandler) {
routes += Route("DELETE", normalizePath(prefix, path), handler)
}
fun delete(handler: RelixHandler) = delete("", handler)
fun route(subPrefix: String, block: RouteGroup.() -> Unit) {
val group = RouteGroup(normalizePath(prefix, subPrefix), routes)
group.block()
}
}
幾個設計重點
RouteGroup 和 RoutingBuilder 共用同一個 routes list,RouteGroup 不會自己收集 routes 再交出去,而是直接往共享的 list 裡面加,巢狀的 route() 建一個新的 RouteGroup,prefix 疊加,但 routes list 是同一個,這樣不管巢狀幾層,最後 RoutingBuilder.build() 拿到的就是所有 routes 的完整列表
每個 HTTP method 都有兩個多載,get(path, handler) 和 get(handler),後者直接委派給 get("", handler),normalizePath 的第三個 early return 會回傳 prefix,所以路徑就是 group 的 prefix 本身
RouteGroup 做好了,但使用者還進不去,頂層的 routing { } 少了 route() 這個入口,先用 TestKit 把期待的寫法寫成整合測試,確認 DSL → Router → 匹配整條路都會通,檔案放 NestedRoutingTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
class NestedRoutingTest {
@Test
fun `route group adds prefix`() {
val app = RelixApplication()
app.routing {
route("/api") {
get("/hello") { ok("Hello from API") }
}
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/api/hello")
assertEquals(200, response.statusCode)
assertEquals("Hello from API", response.bodyAsText())
}
@Test
fun `nested route groups stack prefixes`() {
val app = RelixApplication()
app.routing {
route("/api/v1") {
route("/users") {
get("/{id}") { ok("User: ${pathParam("id")}") }
}
}
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/api/v1/users/42")
assertEquals(200, response.statusCode)
assertEquals("User: 42", response.bodyAsText())
}
@Test
fun `get without path uses group prefix`() {
val app = RelixApplication()
app.routing {
route("/users") {
get { ok("list users") }
}
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/users")
assertEquals(200, response.statusCode)
assertEquals("list users", response.bodyAsText())
}
@Test
fun `multiple methods in same group`() {
val app = RelixApplication()
app.routing {
route("/items") {
get { ok("list") }
post { created("created") }
}
}
val testKit = RelixTestKit(app)
assertEquals(200, testKit.handleRequest("GET", "/items").statusCode)
assertEquals(201, testKit.handleRequest("POST", "/items").statusCode)
}
@Test
fun `routes outside and inside group coexist`() {
val app = RelixApplication()
app.routing {
get("/health") { ok("OK") }
route("/api") {
get("/users") { ok("users") }
}
}
val testKit = RelixTestKit(app)
assertEquals("OK", testKit.handleRequest("GET", "/health").bodyAsText())
assertEquals("users", testKit.handleRequest("GET", "/api/users").bodyAsText())
}
@Test
fun `nested route returns 404 for wrong path`() {
val app = RelixApplication()
app.routing {
route("/api") {
get("/hello") { ok("Hello") }
}
}
val testKit = RelixTestKit(app)
assertEquals(404, testKit.handleRequest("GET", "/hello").statusCode)
}
}
RoutingBuilder 需要一個 route() 方法來建立 RouteGroup,改的是第 10 篇建立的 RoutingBuilder.kt
@RelixDsl
class RoutingBuilder {
fun route(prefix: String, block: RouteGroup.() -> Unit) {
val group = RouteGroup(prefix, routes)
group.block()
}
//...
}
route() 建一個 RouteGroup,把 routes list 傳進去,然後在 group 上面執行 block,因為 block 的型別是 RouteGroup.() -> Unit,所以 block 裡的 this 是 RouteGroup,使用者可以直接呼叫 get()、post()、route() 等方法
注意 RoutingBuilder 的 get/post 不帶 prefix (因為它是頂層),而 RouteGroup 的 get/post 會用 normalizePath 加上 prefix。這是兩者的差異
到這裡你會發現,每次測試都要寫 val app = RelixApplication() + app.routing { } + val testKit = RelixTestKit(app),三行樣板可以包成一個函式,檔案就用第 06 篇的 RelixTestKit.kt,但要注意它是 top-level function,寫在 class 外面
class RelixTestKit(private val application: RelixApplication) {
// ...第 06 篇的 handleRequest 沒有變
}
fun testApplication(
setup: RelixApplication.() -> Unit,
): RelixTestKit {
val app = RelixApplication()
app.setup()
return RelixTestKit(app)
}
它不能寫成 RelixTestKit 的方法,因為這個函式的工作就是生一個 RelixTestKit 出來,如果它是 member,你得先有一個 RelixTestKit 才能呼叫它,測試裡的 testApplication { } 會找不到人
setup 的型別是 RelixApplication.() -> Unit,所以 lambda 裡的 this 是 RelixApplication,你可以直接呼叫 routing { }
用起來長這樣,這個加在 NestedRoutingTest.kt
@Test
fun `testApplication helper works`() {
val testKit = testApplication {
routing {
route("/api") {
get("/hello") { ok("Hello!") }
}
}
}
val response = testKit.handleRequest("GET", "/api/hello")
assertEquals(200, response.statusCode)
}
少了三行樣板,測試的意圖更清楚,而且因為 setup 裡寫的就是使用者真正會寫的 DSL,如果 DSL 設計有問題 (例如 routing { } 不能在某個 scope 裡呼叫),測試會先幫你抓到
前面那六個也可以順手換成這個寫法,往後的篇數都會這樣寫測試
順帶解釋一下為什麼是 RelixApplication.() -> Unit,而不是新做一個 TestApplicationBuilder class 包一層,後者得在 builder 上把 RelixApplication 的每個方法 (routing、install、use...) 都重新轉發一次,每加一個新方法,builder 也得跟著改,兩邊的介面容易逐漸不同,直接用 application 當 receiver,測試與正式 app 使用同一套 DSL
第 28 篇會把
testApplication升級成完整的TestRelixApplication,支援 plugin install 和 Content Negotiation
RouteGroup 和 RoutingBuilder 為什麼不共用一個基底類別 ?
看起來 RouteGroup 和 RoutingBuilder 有很多重複的 get/post 方法,你可能會想抽一個 RouteScope 介面,可以這樣子做,但目前先不做,這兩者的行為有一點差異,RoutingBuilder 的 get(path) 不加 prefix,RouteGroup 的 get(path) 要用 normalizePath 加 prefix,等共同規則穩定後再抽取,會比現在先藏住差異更容易維護,這也是一般人在設計架構上很容易會犯的錯誤,提早優化
route() 裡面可以直接呼叫外層的 routing { } 嗎 ?
不行,第 10 篇加的 @RelixDsl annotation 會擋住這件事,在 RouteGroup 的 lambda 裡,compiler 不讓你隱式呼叫外層 RelixApplication 的 routing(),如果你真的需要 (通常代表設計有問題),要用 this@routing 明確指定
prefix 結尾要不要帶 / ?
我們的 normalizePath 會把 prefix 尾巴的 / 去掉,所以 route("/api/") 和 route("/api") 效果一樣,這是刻意的寬容設計,避免使用者因為多一個 / 就拼出錯誤路徑,前面的文章也有說明過了
巢狀路由的核心是一個 normalizePath 函式和一個帶 prefix 的 RouteGroup,路徑合併規則集中在一個地方,RouteGroup 和 RoutingBuilder 共用 routes list,所以不管巢狀幾層,routes 最後都會交給 Router,testApplication { } 讓測試跟正式用法用同一套 DSL,減少樣板,也不會讓兩邊的寫法愈走愈遠
下一篇會講 Middleware 和 Pipeline 概念,我們會畫出洋蔥模型的流程,定義 RelixMiddleware 的型別簽名,並用具體例子說明 before/after 處理和短路行為
同步刊登於 Blog
圖片來源:AI 產生